SPB Git forge
38commits 1branches 0releases
338.7 MBsize
maindefault branch
6 h agolast push
HTML 53.9% TypeScript 44.5% JavaScript 0.6% SQL 0.5%
21.3 KB · 354 lines tsx
Raw Blame History
1import type { EventDTO, EventType } from "@dci/core";2import type { Metadata } from "next";3import Link from "next/link";4import { notFound } from "next/navigation";5import { Breadcrumbs } from "@/components/entity/breadcrumbs";6import { EntityMap } from "@/components/entity/entity-map";7import { JsonLd } from "@/components/entity/json-ld";8import { ExtLink } from "@/components/entity/kv";9import { fPageHref, PagedFacilities, parseFPage } from "@/components/entity/paged-facilities";10import { SectionNav } from "@/components/entity/section-nav";11import { SourcesFooter } from "@/components/entity/sources-footer";12import { EventList } from "@/components/event-list";13import { ClaimsTable, DataQualityPanel } from "@/components/evidence";14import { FacilityTable } from "@/components/facility-table";15import { ExpansionVelocityBlock } from "@/components/intelligence/operator/velocity";16import { CoverageBar, HonestEmpty, MwFigure, PillLink, PipelineBars, PublishedOnlyNote, ShareList } from "@/components/intelligence/shared";17import { WatchButton } from "@/components/intelligence/watch-button";18import { ProjectList } from "@/components/project-list";19import { Badge } from "@/components/ui/badge";20import { Page, Section } from "@/components/ui/card";21import { StackedBar } from "@/components/ui/charts";22import { CountryFlag } from "@/components/ui/country-flag";23import { Metric, MetricCell, MetricRow } from "@/components/ui/metric";24import { EmptyState, ErrorState, OfflineNotice } from "@/components/ui/states";25import { RowLink, TBody, THead, Table, Td, Th, Tr } from "@/components/ui/table";26import { SourceHistory } from "@/components/ui/timeline";27import { api } from "@/lib/api";28import type { DetailFacilitiesMeta } from "@/lib/dto";29import { countryName, fmtDate, fmtInt, fmtMw, fmtPct, pluralize } from "@/lib/format";30import { OPERATOR_KIND_LABEL } from "@/lib/labels";31import { routes } from "@/lib/routes";32import { compactJsonLd, NOT_FOUND_METADATA, pageMetadata } from "@/lib/seo";33import type { SearchParams } from "@/lib/sort";34import { CloudStatusBadge } from "../../cloud-regions/cloud-format";3536export const revalidate = 300;3738type Props = { params: Promise<{ slug: string }>; searchParams: Promise<SearchParams> };3940function describe(o: { name: string; kind: string | null; facilityCount: number; countryCount: number; knownMw?: number | null; plannedMw?: number | null; hqCountryIso2?: string | null }): string {41  const parts = [`${o.name} — ${o.kind ? OPERATOR_KIND_LABEL[o.kind as keyof typeof OPERATOR_KIND_LABEL].toLowerCase() : "data center operator"}`];42  parts.push(`${fmtInt(o.facilityCount)} ${pluralize(o.facilityCount, "facility", "facilities")} in ${fmtInt(o.countryCount)} ${pluralize(o.countryCount, "country", "countries")}`);43  if (o.knownMw) parts.push(`${fmtMw(o.knownMw)} known operational capacity`);44  if (o.plannedMw) parts.push(`≈ ${fmtMw(o.plannedMw)} in the pipeline`);45  if (o.hqCountryIso2) parts.push(`headquartered in ${countryName(o.hqCountryIso2)}`);46  return `${parts.join(", ")}. Footprint map, pipeline, expansion velocity, geographic diversity, corporate events, claims and data quality on DataCenterIndex.`;47}4849export async function generateMetadata({ params }: Props): Promise<Metadata> {50  const { slug } = await params;51  const r = await api.operator(slug);52  if (!r.ok) return r.status === 404 ? NOT_FOUND_METADATA : { title: "Operator", alternates: { canonical: routes.operator(slug) } };53  return pageMetadata({ title: r.data.name, description: describe(r.data), path: routes.operator(slug) });54}5556/** Corporate events are served separately by the API; everything else on the operator is a physical change. */57const CORPORATE_TYPES = new Set<EventType>(["acquisition", "investment_announced", "partnership", "executive_change", "customer_agreement", "owner_changed", "operator_changed", "power_agreement"]);5859export default async function OperatorPage({ params, searchParams }: Props) {60  const [{ slug }, sp] = await Promise.all([params, searchParams]);61  const fPage = parseFPage(sp.fPage);62  const r = await api.operator(slug, fPage > 1 ? { fPage } : undefined);63  if (!r.ok && r.status === 404) notFound();64  if (!r.ok) {65    return (66      <Page className="py-6">67        <OfflineNotice detail={r.error} className="mb-4" />68        <ErrorState />69      </Page>70    );71  }72  const o = r.data;73  const meta = (r.meta ?? {}) as DetailFacilitiesMeta;74  const facTotal = meta.facilitiesTotal ?? o.facilityCount;75  const facPer = meta.facilitiesPerPage ?? 50;76  const base = routes.operator(slug);77  const kindLabel = o.kind ? OPERATOR_KIND_LABEL[o.kind] : null;78  const aliases = o.aliases.filter((a) => a && a !== o.name);79  const sameAs = Object.values(o.externalIds ?? {})80    .map(String)81    .filter((v) => /^https?:\/\//.test(v));82  const statusTotal = Object.values(o.statusBreakdown).reduce((a, b) => a + (b ?? 0), 0);83  const corporateIds = new Set(o.corporateEvents.map((e) => e.id));84  // physical = every non-corporate event (generic types such as news / page_changed stay on this side rather than being dropped)85  const physical: EventDTO[] = o.recentEvents.filter((e) => !corporateIds.has(e.id) && !CORPORATE_TYPES.has(e.eventType));86  const corporate: EventDTO[] = o.corporateEvents.length ? o.corporateEvents : o.recentEvents.filter((e) => CORPORATE_TYPES.has(e.eventType));87  const constructionMw = o.constructionMw ?? o.pipeline.construction.mw;88  const plannedMw = o.plannedMw ?? (o.pipeline.approved.mw === null && o.pipeline.announced.mw === null ? null : (o.pipeline.approved.mw ?? 0) + (o.pipeline.announced.mw ?? 0));89  const coverage = o.mwCoverage ?? o.pipeline.mwCoverage;90  const cloud = [...o.cloudRegions].sort((a, b) => a.code.localeCompare(b.code));9192  const jsonLd = compactJsonLd({93    "@context": "https://schema.org",94    "@type": "Organization",95    "@id": `${routes.operator(slug)}#org`,96    name: o.name,97    url: o.website ?? undefined,98    alternateName: aliases.length ? aliases : undefined,99    sameAs: sameAs.length ? sameAs : undefined,100    description: o.description ?? describe(o),101    parentOrganization: o.parent ? { "@type": "Organization", name: o.parent.name } : undefined,102    location: o.hqCountryIso2 ? { "@type": "Country", name: countryName(o.hqCountryIso2) } : undefined,103  });104105  const sections = [106    { id: "map", label: "Map", count: o.facilityCount },107    { id: "pipeline", label: "Pipeline" },108    { id: "geography", label: "Geography", count: o.topCountries.length + o.topMetros.length },109    { id: "velocity", label: "Velocity" },110    { id: "events", label: "Events", count: physical.length + corporate.length },111    { id: "ai", label: "AI facilities", count: o.aiFacilities.length },112    { id: "cloud", label: "Cloud regions", count: cloud.length },113    { id: "projects", label: "Projects", count: o.projects.length },114    { id: "facilities", label: "Facilities", count: facTotal },115    { id: "claims", label: "Claims", count: o.claims.length },116    { id: "quality", label: "Data quality" },117    { id: "history", label: "Source history", count: o.sourceHistory.length },118  ];119120  return (121    <Page>122      <JsonLd data={jsonLd} />123      <div className="pt-3">124        <Breadcrumbs items={[{ name: "Operators", href: routes.operators() }, { name: o.name, href: base }]} />125      </div>126127      <header className="py-5 sm:py-7">128        <div className="flex flex-wrap items-center gap-2">129          <div className="label-caps">Operator</div>130          {kindLabel && (131            <Badge tone={o.kind === "hyperscaler" ? "accent" : "outline"} size="xs">132              {kindLabel}133            </Badge>134          )}135          {o.isCloudProvider && (136            <Badge tone="outline" size="xs">137              Cloud provider138            </Badge>139          )}140          {o.isCarrier && (141            <Badge tone="outline" size="xs">142              Carrier143            </Badge>144          )}145        </div>146        <div className="mt-1.5 flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">147          <div className="min-w-0">148            <h1 className="text-[24px] font-semibold leading-tight tracking-[-0.02em] text-ink sm:text-[30px]">{o.name}</h1>149            <div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-[13px] text-ink-2">150              {o.hqCountryIso2 && (151                <span className="inline-flex items-center gap-1.5">152                  <span className="text-ink-3">HQ</span>153                  <Link href={routes.country(o.hqCountryIso2.toLowerCase())} className="inline-flex items-center gap-1.5 hover:text-ink hover:underline">154                    <CountryFlag iso2={o.hqCountryIso2} withName />155                  </Link>156                </span>157              )}158              {o.website && <ExtLink href={o.website} className="no-underline hover:underline" />}159              {o.parent && (160                <span>161                  <span className="text-ink-3">Part of </span>162                  <Link href={routes.operator(o.parent.slug)} className="text-ink hover:text-accent hover:underline">163                    {o.parent.name}164                  </Link>165                </span>166              )}167            </div>168          </div>169          <div className="flex shrink-0 flex-wrap items-center gap-2">170            <WatchButton entityType="operator" slug={o.slug} entityId={o.id} name={o.name} />171            <PillLink href={routes.compareOperators([o.slug])}>Compare with…</PillLink>172          </div>173        </div>174        {o.description && <p className="mt-3 max-w-2xl text-[13.5px] text-ink-2">{o.description}</p>}175        {aliases.length > 0 && (176          <div className="mt-3 flex flex-wrap items-center gap-1.5">177            <span className="text-[11.5px] text-ink-3">Also known as</span>178            {aliases.slice(0, 6).map((a) => (179              <Badge key={a} tone="outline" size="xs" className="max-w-[200px]">180                <span className="truncate">{a}</span>181              </Badge>182            ))}183            {aliases.length > 6 && <span className="text-[11.5px] text-ink-4">+{aliases.length - 6}</span>}184          </div>185        )}186      </header>187188      <MetricRow cols={5}>189        <MetricCell>190          <Metric value={fmtInt(o.facilityCount)} label="Facilities" note={statusTotal ? `${fmtInt(o.pipeline.operational.count)} operational` : undefined} />191        </MetricCell>192        <MetricCell>193          <Metric value={fmtInt(o.countryCount)} label="Countries" />194        </MetricCell>195        <MetricCell>196          <Metric value={o.metroCount === undefined ? "—" : fmtInt(o.metroCount)} label="Markets" />197        </MetricCell>198        <MetricCell>199          <Metric value={<MwFigure mw={o.knownMw} unitClassName="text-[0.5em]" />} label="Known operational MW" note={o.knownMw ? `published figures · ${fmtPct(coverage)} coverage` : `no published figure · ${fmtPct(coverage)} of facilities publish MW`} />200        </MetricCell>201        <MetricCell>202          <Metric value={<MwFigure mw={constructionMw} estimate unitClassName="text-[0.5em]" />} label="Under construction" note={constructionMw === null ? `${fmtInt(o.pipeline.construction.count)} ${pluralize(o.pipeline.construction.count, "site")}, MW not published` : `${fmtInt(o.pipeline.construction.count)} ${pluralize(o.pipeline.construction.count, "site")}`} />203        </MetricCell>204      </MetricRow>205      <MetricRow cols={5} className="border-t-0 [&>div]:border-t-0">206        <MetricCell>207          <Metric size="sm" value={<MwFigure mw={plannedMw} estimate unitClassName="text-[0.5em]" />} label="Planned MW" note={plannedMw === null ? "not published" : "approved + announced"} />208        </MetricCell>209        <MetricCell>210          <Metric size="sm" value={fmtInt(o.aiCount ?? o.aiFacilities.length)} label="AI sites" />211        </MetricCell>212        <MetricCell>213          <Metric size="sm" value={fmtInt(o.cloudRegionCount ?? cloud.length)} label="Cloud regions" note={o.cloudRegionCount || cloud.length ? "cloud exposure" : "none published"} />214        </MetricCell>215        <MetricCell>216          <Metric size="sm" value={fmtInt(o.projectCount ?? o.projects.length)} label="Projects" note={o.projectPlannedMw ? `≈ ${fmtMw(o.projectPlannedMw)} planned` : undefined} />217        </MetricCell>218        <MetricCell>219          <Metric size="sm" value={<CoverageBar value={coverage} width="w-full" label="Capacity coverage" />} label="Capacity coverage" note="share of facilities with a MW figure" />220        </MetricCell>221      </MetricRow>222223      <SectionNav items={sections} className="mt-4" />224225      <div className="flex flex-col gap-8 pt-6">226        <Section id="map" title="Portfolio map" description="Facilities operated; hollow rings are city-level locations." className="scroll-mt-28">227          <EntityMap filters={{ operator: o.slug }} center={{ lat: 25, lng: 10 }} zoom={1.2} openLabel="Open footprint in map" className="h-[240px] sm:h-[360px]" />228          {statusTotal > 0 && (229            <div className="mt-3">230              <div className="label-caps mb-1.5">Status</div>231              <StackedBar data={o.statusBreakdown} height={10} />232            </div>233          )}234        </Section>235236        <Section id="pipeline" title="Pipeline" description="Facilities by lifecycle bucket with known MW — containment-aware (a campus and its buildings are never both counted)." className="scroll-mt-28">237          <PipelineBars pipeline={o.pipeline} />238          <PublishedOnlyNote className="mt-2" />239        </Section>240241        <Section id="geography" title="Geographic diversity" description="Share of the operator's facilities by country and by market." className="scroll-mt-28">242          <div className="grid gap-6 md:grid-cols-2">243            <div>244              <div className="label-caps mb-2">Top countries</div>245              {o.topCountries.length ? <ShareList items={o.topCountries.map((c) => ({ key: c.iso2, label: c.name, href: routes.country(c.slug), count: c.facilityCount, share: c.share, mw: c.knownMw, iso2: c.iso2 }))} /> : <HonestEmpty title="No facility located in a country yet" />}246              {o.countries.length > o.topCountries.length && <p className="mt-2 text-[11.5px] text-ink-4">Top {o.topCountries.length} of {o.countries.length} countries — the facility table covers all of them.</p>}247            </div>248            <div>249              <div className="label-caps mb-2">Top markets</div>250              {o.topMetros.length ? <ShareList items={o.topMetros.map((m) => ({ key: m.id, label: m.name, href: routes.market(m.slug), count: m.facilityCount, share: m.share, mw: m.knownMw, iso2: m.countryIso2 }))} /> : <HonestEmpty title="No facility assigned to a market yet" detail="facilities outside any defined metro are not counted here" />}251              {o.metros.length > o.topMetros.length && <p className="mt-2 text-[11.5px] text-ink-4">Top {o.topMetros.length} of {o.metros.length} markets.</p>}252            </div>253          </div>254        </Section>255256        <Section id="velocity" title="Expansion velocity" description="New facilities, projects, countries and markets per window; the basis of every date is labelled." className="scroll-mt-28">257          <ExpansionVelocityBlock velocity={o.velocity} />258        </Section>259260        <Section id="events" title="Recent events" description="Physical changes (facilities, projects, openings, capacity) are kept apart from corporate events (acquisitions, financing, partnerships, executive changes)." className="scroll-mt-28" aside={<Link href={routes.live(`operator=${o.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">Live feed →</Link>}>261          <div className="grid gap-6 lg:grid-cols-2">262            <div>263              <h3 className="mb-1.5 text-[13px] font-semibold text-ink">264                Physical <span className="figure text-[11px] font-normal text-ink-3">{fmtInt(physical.length)}</span>265              </h3>266              <EventList events={physical.slice(0, 12)} dense emptyTitle="No physical change detected yet" emptyDescription="Facility, project, opening and capacity changes appear here as connectors observe them." />267            </div>268            <div>269              <h3 className="mb-1.5 text-[13px] font-semibold text-ink">270                Corporate <span className="figure text-[11px] font-normal text-ink-3">{fmtInt(corporate.length)}</span>271              </h3>272              <EventList events={corporate.slice(0, 12)} dense emptyTitle="No corporate event recorded" emptyDescription="Acquisitions, financing, partnerships and executive changes are attached to the operator, never turned into projects." />273            </div>274          </div>275        </Section>276277        <Section id="ai" title="AI facilities" description="Sites with confirmed or likely AI / HPC evidence; a single keyword never confirms a site." className="scroll-mt-28" aside={<Link href={routes.ai()} className="text-[12px] text-ink-3 hover:text-ink">AI index →</Link>}>278          {o.aiFacilities.length ? <FacilityTable items={o.aiFacilities} columns={["market", "country", "status", "mw", "type"]} /> : <HonestEmpty title="No AI / HPC facility with evidence" detail="facilities appear here when a source describes them as AI, HPC, GPU or accelerated-computing infrastructure" />}279        </Section>280281        <Section id="cloud" title="Cloud regions" description={o.isCloudProvider ? "Public cloud regions operated by this provider." : "Cloud regions associated with this operator's markets or tenants."} className="scroll-mt-28">282          {cloud.length ? (283            <Table dense mobileCollapse={{ total: cloud.length, label: "cloud regions" }}>284              <THead>285                <tr>286                  <Th>Code</Th>287                  <Th className="hidden sm:table-cell">Region</Th>288                  <Th>Country</Th>289                  <Th numeric className="hidden md:table-cell">290                    AZs291                  </Th>292                  <Th>Status</Th>293                  <Th className="hidden lg:table-cell">Launched</Th>294                </tr>295              </THead>296              <TBody>297                {cloud.map((cr) => (298                  <Tr key={cr.id}>299                    <Td primary>300                      <RowLink href={routes.cloudRegion(cr.slug)} className="figure text-[12.5px]">301                        {cr.code}302                      </RowLink>303                      <div className="text-[11.5px] text-ink-3 sm:hidden">{cr.name}</div>304                    </Td>305                    <Td label="Region" className="hidden text-ink-2 sm:table-cell">306                      {cr.name}307                    </Td>308                    <Td label="Country">309                      <CountryFlag iso2={cr.countryIso2} withName className="max-w-[160px] text-ink-2" />310                    </Td>311                    <Td label="AZs" numeric className="hidden md:table-cell">312                      {cr.availabilityZones ?? <span className="text-ink-4">—</span>}313                    </Td>314                    <Td label="Status">315                      <CloudStatusBadge status={cr.status} />316                    </Td>317                    <Td label="Launched" className="figure hidden text-[12px] text-ink-3 lg:table-cell">318                      {fmtDate(cr.launchedOn)}319                    </Td>320                  </Tr>321                ))}322              </TBody>323            </Table>324          ) : (325            <HonestEmpty title="No cloud region linked" detail="cloud exposure is published by the providers' region lists" />326          )}327        </Section>328329        <Section id="projects" title="Projects" description="Announced, permitted and under-construction developments linked to this operator (announcements, not facilities)." className="scroll-mt-28" aside={o.projects.length > 0 ? <Link href={routes.projects(`operator=${o.slug}`)} className="text-[12px] text-ink-3 hover:text-ink">All projects →</Link> : undefined}>330          <ProjectList items={o.projects} emptyTitle="No project linked to this operator" emptyDescription="Projects are created only from announcements that name a site, a location and a development verb." />331        </Section>332333        <Section id="facilities" title="Facilities" description={`${fmtInt(facTotal)} indexed · sorted by known capacity`} className="scroll-mt-28">334          <PagedFacilities items={o.facilities} total={facTotal} page={fPage} perPage={facPer} hrefFor={(p) => fPageHref(base, sp, p)} columns={["market", "country", "status", "mw", "type", "confidence"]} />335        </Section>336337        <Section id="claims" title="Claims" description="Every capacity and investment figure asserted about this operator, with its scope. Portfolio and company figures are shown as evidence, never summed into facilities." className="scroll-mt-28">338          <ClaimsTable claims={o.claims} kind="all" emptyNote="No operator-level claim has been extracted yet — portfolio and company figures appear here once a document asserts one." />339        </Section>340341        <Section id="quality" title="Data quality" description="Sources, completeness, claims by status and open sanity flags for this record." className="scroll-mt-28">342          <DataQualityPanel q={o.dataQuality} />343        </Section>344345        <Section id="history" title="Source history" description="Observations and page changes that shaped this record." className="scroll-mt-28">346          {o.sourceHistory.length ? <SourceHistory items={o.sourceHistory.slice(0, 40)} /> : <EmptyState compact title="No source history yet" />}347        </Section>348      </div>349350      <SourcesFooter sources={r.sources} updatedAt={o.updatedAt} note={o.knownMw === null ? `No operational MW figure has been published by the sources indexed for ${o.name}; counts are exact, capacities appear only when a source publishes them.` : undefined} />351    </Page>352  );353}354